🎖️GitЯра🎖️
docs/en/developer/measurement.md bd2863243bab6eb213401d949839a2bc74dde7e2 (bd286324) Text, 6.60 KB
---
title: Measurement & Formatting
parent: Developer Guide
nav_order: 9
last_updated: 2026-08-19
aliases:
Tff7b72- measurement
Tff7b72- metric-formatter
Tc9d1d9 - number-formatter
Tc9d1d9---
Tc9d1d9# Measurement & Formatting
How the Meshtastic Android/KMP app formats numbers, units, and locale-sensitive values.
---
Tc9d1d9## Overview
All measurement data transmitted by Meshtastic radios uses **metric units** (meters, °C, hPa, m/s, etc.). The app converts and formats these values for display using two core utilities:
| Utility | Location | Purpose |
|---|---|---|
| Ta5d6ff`MetricFormatter` | Ta5d6ff`core/common/.../util/MetricFormatter.kt` | Converts and formats physical measurements (temperature, pressure, speed, etc.) |
| Ta5d6ff`NumberFormatter` | Ta5d6ff`core/common/.../util/NumberFormatter.kt` | Low-level fixed-point number formatting with locale-independent dot separator |
Both live in Ta5d6ff`org.meshtastic.core.common.util` and are available to all KMP targets (Android, Desktop, iOS).
---
Tc9d1d9## MetricFormatter API
Ta5d6ff`MetricFormatter` is a Kotlin Ta5d6ff`object` with pure functions for each measurement type:
Ta5d6ff```Ta5d6ffkotlin
Tff7b72object T56d364MetricFormatter Tb4b4b4{
Tff7b72fun Td2a8fftemperatureTb4b4b4(Te6edf3celsiusTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3isFahrenheitTb4b4b4: Tffa657BooleanTb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffvoltageTb4b4b4(Te6edf3voltsTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff2Tb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffcurrentTb4b4b4(Te6edf3milliAmpsTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff1Tb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffpercentTb4b4b4(Te6edf3valueTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff1Tb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffhumidityTb4b4b4(Te6edf3valueTb4b4b4: Tffa657FloatTb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffpressureTb4b4b4(Te6edf3hPaTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff1Tb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffsnrTb4b4b4(Te6edf3valueTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff1Tb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffrssiTb4b4b4(Te6edf3valueTb4b4b4: Tffa657IntTb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffwindSpeedTb4b4b4(Te6edf3metersPerSecondTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3isImperialTb4b4b4: Tffa657BooleanTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff1Tb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffrainfallTb4b4b4(Te6edf3millimetersTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3isImperialTb4b4b4: Tffa657BooleanTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff1Tb4b4b4)Tb4b4b4: Tffa657String
Tb4b4b4}
Ta5d6ff```
Tc9d1d9### Usage
Ta5d6ff```Ta5d6ffkotlin
T8b949e// Temperature — Fahrenheit conversion is handled automatically
Te6edf3MetricFormatterTb4b4b4.Te6edf3temperatureTb4b4b4(T79c0ff2T79c0ff2.5fTb4b4b4, Te6edf3isFahrenheit Tff7b72= Tff7b72trueTb4b4b4) T8b949e// "72.5°F"
Te6edf3MetricFormatterTb4b4b4.Te6edf3temperatureTb4b4b4(T79c0ff2T79c0ff2.5fTb4b4b4, Te6edf3isFahrenheit Tff7b72= Tff7b72falseTb4b4b4) T8b949e// "22.5°C"
T8b949e// Signal metrics
Te6edf3MetricFormatterTb4b4b4.Te6edf3snrTb4b4b4(Tff7b72-T79c0ff5.2fTb4b4b4) T8b949e// "-5.2 dB"
Te6edf3MetricFormatterTb4b4b4.Te6edf3rssiTb4b4b4(Tff7b72-T79c0ff9T79c0ff7Tb4b4b4) T8b949e// "-97 dBm"
T8b949e// Environment
Te6edf3MetricFormatterTb4b4b4.Te6edf3pressureTb4b4b4(T79c0ff1T79c0ff0T79c0ff1T79c0ff3.25fTb4b4b4) T8b949e// "1013.3 hPa"
Te6edf3MetricFormatterTb4b4b4.Te6edf3humidityTb4b4b4(T79c0ff6T79c0ff5.0fTb4b4b4) T8b949e// "65%"
Te6edf3MetricFormatterTb4b4b4.Te6edf3windSpeedTb4b4b4(T79c0ff3.7fTb4b4b4, Te6edf3isImperial Tff7b72= Tff7b72falseTb4b4b4) T8b949e// "3.7 m/s"
Te6edf3MetricFormatterTb4b4b4.Te6edf3windSpeedTb4b4b4(T79c0ff3.7fTb4b4b4, Te6edf3isImperial Tff7b72= Tff7b72trueTb4b4b4) T8b949e// "8.3 mph"
Te6edf3MetricFormatterTb4b4b4.Te6edf3rainfallTb4b4b4(T79c0ff1T79c0ff2.3fTb4b4b4, Te6edf3isImperial Tff7b72= Tff7b72falseTb4b4b4) T8b949e// "12.3 mm"
Te6edf3MetricFormatterTb4b4b4.Te6edf3rainfallTb4b4b4(T79c0ff1T79c0ff2.3fTb4b4b4, Te6edf3isImperial Tff7b72= Tff7b72trueTb4b4b4) T8b949e// "0.5 in"
T8b949e// Power
Te6edf3MetricFormatterTb4b4b4.Te6edf3voltageTb4b4b4(T79c0ff3.95fTb4b4b4) T8b949e// "3.95 V"
Te6edf3MetricFormatterTb4b4b4.Te6edf3currentTb4b4b4(T79c0ff1T79c0ff2T79c0ff5.0fTb4b4b4) T8b949e// "125.0 mA"
Ta5d6ff```
---
Tc9d1d9## NumberFormatter
Ta5d6ff`NumberFormatter` provides locale-independent decimal formatting using pure arithmetic (no Ta5d6ff`String.format` or Ta5d6ff`DecimalFormat`):
Ta5d6ff```Ta5d6ffkotlin
Tff7b72object T56d364NumberFormatter Tb4b4b4{
Tff7b72fun Td2a8ffformatTb4b4b4(Te6edf3valueTb4b4b4: Tffa657DoubleTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657IntTb4b4b4)Tb4b4b4: Tffa657String
Tff7b72fun Td2a8ffformatTb4b4b4(Te6edf3valueTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657IntTb4b4b4)Tb4b4b4: Tffa657String
Tb4b4b4}
Ta5d6ff```
Tff7b72> **Why locale-independent?** Meshtastic is a mesh networking app where consistency matters — sensor readings shared between nodes should look the same everywhere. `NumberFormatter` always uses `.` as the decimal separator.
---
Tc9d1d9## Unit Conversion
Three measurements convert away from metric for display, each gated by a boolean flag sourced from the user's device locale or preferences:
| Measurement | Flag | Source | Conversion |
|---|---|---|---|
| Ta5d6ff`temperature` | Ta5d6ff`isFahrenheit` | Ta5d6ff`getSystemTemperatureUnit()` | Ta5d6ff`°F = °C × 1.8 + 32` |
| Ta5d6ff`windSpeed` | Ta5d6ff`isImperial` | Ta5d6ff`getSystemMeasurementSystem()` | m/s × 2.23694 → mph |
| Ta5d6ff`rainfall` | Ta5d6ff`isImperial` | Ta5d6ff`getSystemMeasurementSystem()` | mm ÷ 25.4 → in |
The two source functions (in Ta5d6ff`core/common/.../util/MeasurementSystem.kt`) are deliberately separate: some locales mix systems (the UK uses miles for distance but Celsius for temperature), so temperature must never be derived from the distance unit. On Android, Ta5d6ff`getSystemTemperatureUnit()` delegates to Ta5d6ff`androidx.core.text.util.LocalePreferences`, which resolves CLDR locale data and honors the Android 14+ Regional preferences temperature override.
Everything else (voltage, current, pressure, SNR, RSSI, humidity, percent) displays in its native metric units. The user-facing [Tff7b72Units & Locale](Te6edf3../user/units-and-locale) page explains what end users see.
---
Tc9d1d9## Adding a New Measurement Type
To add a new measurement formatter:
Tff7b721. **Add a function to `MetricFormatter`** in Ta5d6ff`core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt`:
Ta5d6ff ```Ta5d6ffkotlin
Tff7b72fun Td2a8ffradiationTb4b4b4(Te6edf3microSievertsTb4b4b4: Tffa657FloatTb4b4b4, Te6edf3decimalPlacesTb4b4b4: Tffa657Int Tff7b72= T79c0ff2Tb4b4b4)Tb4b4b4: Tffa657String Tff7b72=
Ta5d6ff"Tffd700${Te6edf3NumberFormatterTb4b4b4.Te6edf3formatTb4b4b4(Te6edf3microSievertsTb4b4b4, Te6edf3decimalPlacesTb4b4b4)Tffd700}Ta5d6ff μSv/hTa5d6ff"
Ta5d6ff ```
Tff7b722. **Add tests** in Ta5d6ff`core/common/src/commonTest/`:
Ta5d6ff ```Ta5d6ffkotlin
Tf0883e@Test
Tff7b72fun Td2a8ffradiationFormattingTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3assertEqualsTb4b4b4(Ta5d6ff"Ta5d6ff0.15 μSv/hTa5d6ff"Tb4b4b4, Te6edf3MetricFormatterTb4b4b4.Te6edf3radiationTb4b4b4(T79c0ff0.15fTb4b4b4)Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Ta5d6ff"Ta5d6ff1.23 μSv/hTa5d6ff"Tb4b4b4, Te6edf3MetricFormatterTb4b4b4.Te6edf3radiationTb4b4b4(T79c0ff1.234fTb4b4b4)Tb4b4b4)
Tb4b4b4}
Ta5d6ff ```
Tff7b723. **Use in UI** — call from any Ta5d6ff`commonMain` composable or ViewModel:
Ta5d6ff ```Ta5d6ffkotlin
Te6edf3TextTb4b4b4(Te6edf3text Tff7b72= Te6edf3MetricFormatterTb4b4b4.Te6edf3radiationTb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3radiationLevelTb4b4b4)Tb4b4b4)
Ta5d6ff ```
Tff7b724. **Run verification**:
Ta5d6ff ```Ta5d6ffbash
./gradlew :core:common:allTests
Ta5d6ff ```
---
Tc9d1d9## DateFormatter
Date and time formatting uses the Ta5d6ff`DateFormatter` Ta5d6ff`expect object` with platform-specific Ta5d6ff`actual` implementations:
| Function | Output Example |
|---|---|
| Ta5d6ff`formatRelativeTime()` | "5 min ago" |
| Ta5d6ff`formatDateTime()` | "May 13, 2026 2:30 PM" |
| Ta5d6ff`formatShortDate()` | "May 13" |
| Ta5d6ff`formatTime()` | "2:30 PM" |
| Ta5d6ff`formatTimeWithSeconds()` | "2:30:45 PM" |
| Ta5d6ff`formatDate()` | "2026-05-13" |
| Ta5d6ff`formatDateTimeShort()` | "5/13/26 2:30 PM" |
Unlike Ta5d6ff`MetricFormatter`, Ta5d6ff`DateFormatter` is declared with Ta5d6ff`expect`/Ta5d6ff`actual` (an Ta5d6ff`expect object` in Ta5d6ff`commonMain`, an Ta5d6ff`actual object` per platform) because date formatting inherently depends on platform locale APIs.
---
Tc9d1d9## Design Decisions
| Decision | Rationale |
|---|---|
| Locale-independent decimal separator (Ta5d6ff`.`) | Mesh data shared between nodes must be consistent |
| Pure arithmetic formatting (no Ta5d6ff`DecimalFormat`) | Works identically on JVM, Native, and JS targets |
| Only temperature, wind speed, and rainfall convert | The remaining metric units are universally understood in their native form |
| Ta5d6ff`object` singleton pattern | Stateless utility — no instance management needed |
---
Tc9d1d9## Related
Tff7b72- **User-facing docs**: [Tff7b72Units & Locale](Te6edf3../user/units-and-locale) explains what end users see
Tff7b72- **Source code**: Ta5d6ff`core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt`
Tff7b72- **Tests**: Ta5d6ff`core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt`
Served by rngit 1.5.2 - Generated in 0.12s